查看原文
其他

FFmpeg源码世界:命令篇

gezilinII 音视频开发进阶 2022-04-25

前言

最近在做一些音视频领域的工作,这个领域基本绕不开 FFmpeg ,因此想对其源码进行一些研究,站着这个巨人的肩膀上,学习一下其设计思想以及实现思路,FFmpeg 很多人最开始接触的应该都是它的命令和 ffplay ,这篇我们就先分析下 ffmpeg 命令 的实现。

从问题出发

  • FFmpeg的命令结构是什么样的
  • 怎么实现任意功能、配置的随意组装的
  • 倒放这种非线性的情况是怎么处理的 *当不需要转码类似只需要转封装的话是如何处理的

命令结构

ffmpeg [global_options] {[input_file_options] -i input_url} ... {[output_file_options] output_url} ...

流程推测

  1. 应该要有一个数据层,按照输入参数进行数据解析
  2. 将解码、转码、功能处理、编码四个模块抽象分开
  3. 按第一个环节数据解析的结构组装上述四个模块,形成一条或多条责任链的效果
  4. 开始处理

过程中应该有很多分支判断、参数设置之类的情况,在这里都不考虑,我们先只分析主链路。

主流程

首先 ffmpeg 命令的入口在于 fftools/ffmpeg.c 的 main 函数上,先从这个函数来看下整体的流程,代码用的是 ffmpeg 4.4 的版本,同时会省略一些无关紧要的内容:

int main(int argc, char **argv)
{
    /* 一些前置的初始化动作 */
        ...

    /* 数据解析函数,不过在这个函数里面还会去开启输入/输出文件流等处理 */
    ret = ffmpeg_parse_options(argc, argv);

    /* 一些数据判断、开启基准测试之类的处理 */
        ... 

    /* 开始做实际的文件转换,即按命令开始处理了 */  
    if (transcode() < 0)
        ...

    /* 结束了,统计耗时等等 */
    ...
}

从上述代码可以看到核心的环节就是两部分:数据解析 + 文件转换,接下来我们将针对这两部分再深入进去看看。

接下去我们分析过程中使用的命令如下:

ffmpeg -i douyin_700x1240.mp4 -vf reverse -af areverse reversed.mp4

上述命令是将一个 douyin_700x1240.mp4 这个视频中的视频流于音频流同时倒放,生成 reversed.mp4。

数据解析

在分析 ffmpeg_parse_options 函数之前,我们先介绍下 FFmpeg 命令中参数配置相关的几个主要的结构体:

OptionParseContext

typedef struct OptionParseContext {
    OptionGroup global_opts; // 全局配置参数集

    OptionGroupList *groups; // 输出/输入配置参数集
    int           nb_groups; // groups的长度

    /* parsing state */
    OptionGroup cur_group; // 下面讲解到 add_opt 的时候会提到
} OptionParseContext;

该对象用来存储被解析后的数据,全局参数,输入输出参数等。

OptionGroupList

/**
 * A list of option groups that all have the same group type
 * (e.g. input files or output files)
 */
typedef struct OptionGroupList {
    const OptionGroupDef *group_def;

    OptionGroup *groups;
    int       nb_groups;
} OptionGroupList;

上面的注释已经解释得比较清楚了。

OptionGroup

typedef struct OptionGroup {
    const OptionGroupDef *group_def;
    const char *arg;

    Option *opts;
    int  nb_opts;

    AVDictionary *codec_opts;
    AVDictionary *format_opts;
    AVDictionary *resample_opts;
    AVDictionary *sws_dict;
    AVDictionary *swr_opts;
} OptionGroup;

这里就是各类参数的存储地了,再往下就是单个配置参数的存储地 Option 了。

Option

/**
 * An option extracted from the commandline.
 * Cannot use AVDictionary because of options like -map which can be
 * used multiple times.
 */

typedef struct Option {
    const OptionDef  *opt;
    const char       *key;
    const char       *val;
} Option;

就是配置的键值对和定义规范了。

OptionDef / OptionGroupDef

这里就是各种参数的规范定义了,比如 OptionGroupDef :

typedef struct OptionGroupDef {
    /**< group name */
    const char *name;
    /**
     * Option to be used as group separator. Can be NULL for groups which
     * are terminated by a non-option argument (e.g. ffmpeg output files)
     */

    const char *sep;
    /**
     * Option flags that must be set on each option that is
     * applied to this group
     */

    int flags;
} OptionGroupDef;

另外 ffmpeg 支持的配置列表在 ffmpeg_opt.c中:

#define OFFSET(x) offsetof(OptionsContext, x)
const OptionDef options[] = {
    /* main options */
    CMDUTILS_COMMON_OPTIONS
    { "f",              HAS_ARG | OPT_STRING | OPT_OFFSET |
                        OPT_INPUT | OPT_OUTPUT,                      { .off       = OFFSET(format) },
        "force format""fmt" },
    { "y",              OPT_BOOL,                                    {              &file_overwrite },
    .......ffmpeg_parse_options

ffmpeg_parse_options

现在开始让我们看下解析函数:

作者:gezilinll
链接:https://zhuanlan.zhihu.com/p/380359900
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

int ffmpeg_parse_options(int argc, char **argv)
{
    ......

    /* 将命令行的参数进行拆分,将拆分结果分类并设置到不同的数据结构中 */
    ret = split_commandline(&octx, argc, argv, options, groups,
                            FF_ARRAY_ELEMS(groups));
    ......

    /* 这里解析全局的配置参数,我们这个范例里面没有直接跳过,跟上面的解析主要是细节处理上的不同,不赘述 */
    ret = parse_optgroup(NULL, &octx.global_opts);
    if (ret < 0) {
        av_log(NULL, AV_LOG_FATAL, "Error splitting the argument list: ");
        goto fail;
    }

    /* 开启配置中的所有输入文件流 */
    /* open_input_file 接口内如果有配置文件的起始处理时间的话就会去做 Seek 操作 */
    ret = open_files(&octx.groups[GROUP_INFILE], "input", open_input_file);
    if (ret < 0) {
        av_log(NULL, AV_LOG_FATAL, "Error opening input files: ");
        goto fail;
    }

    /* 如果参数中有设置滤镜相关的话则进行初始化,我们这里不涉及,就先略过 */
    ret = init_complex_filters();
    if (ret < 0) {
        av_log(NULL, AV_LOG_FATAL, "Error initializing complex filters.\n");
        goto fail;
    }

    /* 开启输出文件流 */
    ret = open_files(&octx.groups[GROUP_OUTFILE], "output", open_output_file);
    if (ret < 0) {
        av_log(NULL, AV_LOG_FATAL, "Error opening output files: ");
        goto fail;
    }

    ......
}

在上面的代码中,我们重点关注下 split_commandline ,其他函数按注释知道其作用即可,大多是一些常规逻辑上的内容,不同的业务场景可能有不同的设计思路,不影响我们当前的分析:

作者:gezilinll
链接:https://zhuanlan.zhihu.com/p/380359900
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

int split_commandline(OptionParseContext *octx, int argc, char *argv[],
                      const OptionDef *options,
                      const OptionGroupDef *groups, int nb_groups)
{
    ...

    /* 初始化 OptionParseContext 内全局、输入、输出参数的数据对象,包括分配内存等 */
    init_parse_context(octx, groups, nb_groups);
    av_log(NULL, AV_LOG_DEBUG, "Splitting the commandline.\n");

    /* 这里开始遍历参数了 */
    while (optindex < argc) {
        const char *opt = argv[optindex++], *arg;
        const OptionDef *po;
        int ret;

        av_log(NULL, AV_LOG_DEBUG, "Reading option '%s' ...", opt);
                /* --xxx的跳过 */
        if (opt[0] == '-' && opt[1] == '-' && !opt[2]) {
            dashdash = optindex;
            continue;
        }
        /* 参数类型为不带 - 或者 第二个字符是空的 比如 reversed.mp4,匹配到了就跳到下一个参数 */
        if (opt[0] != '-' || !opt[1] || dashdash+1 == optindex) {
            /* 这里就是找到对应的 OptionGroup 对其中的参数进行赋值 */
            finish_group(octx, 0, opt);
            av_log(NULL, AV_LOG_DEBUG, " matched as %s.\n", groups[0].name);
            continue;
        }
        opt++;

                ......

        /* 匹配当前参数是否-i的配置,并获取其后的参数如douyin_700x1240.mp4,匹配到了就跳到下一个参数 */
        if ((ret = match_group_separator(groups, nb_groups, opt)) >= 0) {
            GET_ARG(arg);
            finish_group(octx, ret, arg);
            av_log(NULL, AV_LOG_DEBUG, " matched as %s with argument '%s'.\n",
                   groups[ret].name, arg);
            continue;
        }

        /* 匹配当前参数是否是之前提到的 options 中定义的参数,如我们范例的 -vf */
        po = find_option(options, opt);
        /* po->name 就是 vf */
        if (po->name) {
            if (po->flags & OPT_EXIT) {
                /* optional argument, e.g. -h */
                arg = argv[optindex++];
            } else if (po->flags & HAS_ARG) {
                GET_ARG(arg);
            } else {
                arg = "1";
            }
                        /* 将解析到的参数配置设置到输入或输出Group的数据结构里面 */
            /* 根据options中预存的flag判断之后,负责把参数放入octx->cur_group或者global_opts中,目前从代码上了解到的,
               这个 cur_group 就存放不归属上面的几种条件的数据,最后做下提示,因为对开篇提到的问题没啥影响,先略过吧。 */
            add_opt(octx, po, opt, arg); 
            av_log(NULL, AV_LOG_DEBUG, " matched as option '%s' (%s) with "
                   "argument '%s'.\n", po->name, po->help, arg);
            continue;
        }

        /* 接下去就是其他一些类型数据的匹配、解析、设置了,比如-nofoo等,不再赘述 */
        ...

        /* 匹配失败的参数就会报错,就我们常见的错误提示 */
        av_log(NULL, AV_LOG_ERROR, "Unrecognized option '%s'.\n", opt);
        return AVERROR_OPTION_NOT_FOUND;
    }

    ...
}

解析结果

经过上面这样一轮解析后, ffmpeg -i douyin_700x1240.mp4 -vf reverse -af areverse reversed.mp4 这样一行命令的数据将按如下结构存放:

流程图

文件转换

分析完参数解析就该看接下来文件转换是如何处理的了,这里我们主要分析 transcode 这个函数的逻辑,并通过这个函数扩展一下其他相关的部分:

作者:gezilinll
链接:https://zhuanlan.zhihu.com/p/380359900
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

/*
 * The following code is the main loop of the file converter
 */
static int transcode(void)
{
    int ret, i;
    AVFormatContext *os;
    OutputStream *ost;
    InputStream *ist;
    int64_t timer_start;
    int64_t total_packets_written = 0;

    /* 在这个接口里面会去做一堆的比如帧率计算、缓冲计算、编解码器初始化等操作,最后对所有的输出文件进行写文件头的操作,完成初始化 */
    ret = transcode_init();

    ......

    /* 未接收到停止信号的话就持续进行转换处理,直到完成或接收到停止信号 */
    while (!received_sigterm) {
        ......

        /* 这里就是单步转换操作了,后面会进入代码内部近一步介绍 */
        ret = transcode_step();
        ...
    }

    ......

    /* 把解码缓冲区的剩余数据再处理一下然后 flush 准备收工 */
    for (i = 0; i < nb_input_streams; i++) {
        ist = input_streams[i];
        if (!input_files[ist->file_index]->eof_reached) {
            process_input_packet(ist, NULL, 0);
        }
    }
    flush_encoders();

    term_exit();

    /* 写一下文件尾 */
    for (i = 0; i < nb_output_files; i++) {
        os = output_files[i]->ctx;
        if (!output_files[i]->header_written) {
            av_log(NULL, AV_LOG_ERROR,
                   "Nothing was written into output file %d (%s), because "
                   "at least one of its streams received no packets.\n",
                   i, os->url);
            continue;
        }
        if ((ret = av_write_trailer(os)) < 0) {
            av_log(NULL, AV_LOG_ERROR, "Error writing trailer of %s: %s\n", os->url, av_err2str(ret));
            if (exit_on_error)
                exit_program(1);
        }
    }

    /* 接下去就是一些关闭 codec 呀关闭 file 呀之类的操作了 */
    ......

    return ret;
}

上述代码可以看到,核心的部分就在于 transcode_step 里面:

作者:gezilinll
链接:https://zhuanlan.zhihu.com/p/380359900
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

/**
 * Run a single step of transcoding.
 *
 * @return  0 for success, <0 for error
 */
static int transcode_step(void)
{
    OutputStream *ost;
    InputStream  *ist = NULL;
    int ret;

    /* 通过对比当前编码的pts,挑选出最小值的输出流 */
    ost = choose_output();

    ......

    if (ost->filter && ost->filter->graph->graph) {
                ......
        /* 这里就是按照输出配置的 filter 链路对下一步的 process_input 输出帧做处理了,当进来这里时则可能通过其内部的 reap_filters 进行编码而不是后面的 reap_filters */  
        if ((ret = transcode_from_filter(ost->filter->graph, &ist)) < 0)
            return ret;
        if (!ist)
            return 0;
    } else if (ost->filter) {
        int i;
        for (i = 0; i < ost->filter->graph->nb_inputs; i++) {
            InputFilter *ifilter = ost->filter->graph->inputs[i];
            if (!ifilter->ist->got_output && !input_files[ifilter->ist->file_index]->eof_reached) {
                ist = ifilter->ist;
                break;
            }
        }
        if (!ist) {
            ost->inputs_done = 1;
            return 0;
        }
    } else {
        av_assert0(ost->source_index >= 0);
        ist = input_streams[ost->source_index];
    }

    /* 读取一个 AVPacket 当然也包括了一些pts校验计算等等事情,内部先在 get_input_packet 接口中通过 av_read_frame 获取到Packet */
    /* 接着调用 process_input_packet --> decode_audio/decode_video 从而实现解码并将解码帧放入内部数据队列中 */
    /* 解码后的数据会通过 send_frame_to_filters 做一下 FilterGraph 的初始化和配置,以及通过 av_buffersrc_add_frame 将解码后的 AVFrame 送入 AVFilterContext */
    ret = process_input(ist->file_index);
    if (ret == AVERROR(EAGAIN)) {
        if (input_files[ist->file_index]->eagain)
            ost->unavailable = 1;
        return 0;
    }

    if (ret < 0)
        return ret == AVERROR_EOF ? 0 : ret;

    /* 根据滤波器做滤波处理,并把处理完的音视频编码到输出文件中 */
    /* 内部通过调用 do_video_out/do_audio_out 最后在 write_packet 中执行编码操作 */
    return reap_filters(0);
}

transcode_step 整体的流程就是上面代码中介绍的,不过我们范例中的例子是一个倒放的功能,它在\最后编码的时候并不会走最下面的 reap_filters 而是走 transcode_from_filter 中的 reap_filters ,因为我们是用 -vf reverse 这个 filter 来处理输出的。ffmpeg 这个 filter 处理的逻辑会等待解码数据完全准备好了,再倒序进行编码,因此会造成较大的内存压力,特别是移动端上。

如果我们命令是抽音频流,比如 ffmpeg -i douyin_700x1240.mp4 audio.aac,那么走的就是最下面的 reap_filters  了。这里可以多提一下就是我们刚开始说的抽流的情况,当不需要又一次编码的话那么 ffmpeg 命令在 process_intput_packet 中将直接直接调用 do_streamcopy :

static int process_input_packet(InputStream *ist, const AVPacket *pkt, int no_eof)
{
    ......

    for (i = 0; i < nb_output_streams; i++) {
        OutputStream *ost = output_streams[i];

        if (!ost->pkt && !(ost->pkt = av_packet_alloc()))
            exit_program(1);
        if (!check_output_constraints(ist, ost) || ost->encoding_needed)
            continue;

        do_streamcopy(ist, ost, pkt);
    }

    return !eof_reached;
}

最后再思考倒放这个范例的最后一个问题,从代码逻辑来看真正能开始执行编码的条件简单来说有两个,一个是 ost->filter->graph->graph 非空,另一个就是 transcode_from_filter 内部的 avfilter_graph_request_oldest 返回值 >=0 ,那么什么情况下这两个状态能满足呢?不过部分目前我还没去深入研究,留着对 AVFilter 模块的学习时再一起收尾,这里先知道是这样一个逻辑就行了。

流程图

总结

至此我们对 ffmpeg 命令实现的主流程做了一次分析,整体实现的思路跟我们一开始预测的还是有些接近的,不过 ffmpeg 的组装主要还是通过其 AVFilter 及相关的模块来处理的,其 FilterGraph 应该就相当于一个更复杂的责任链,不过说归说,实际看其落地代码当中还是有数之不尽的细节和需要解决的问题的,后面的 FFmpeg 源码世界的其他章节将再做更进一步的分析学习。

作者:gezilinll

链接:https://zhuanlan.zhihu.com/p/380359900


技术交流,欢迎加我微信:ezglumes ,拉你入技术交流群。

推荐阅读:

音视频面试基础题

OpenGL ES 学习资源分享

开通专辑 | 细数那些年写过的技术文章专辑

NDK 学习进阶免费视频来了

推荐几个堪称教科书级别的 Android 音视频入门项目

觉得不错,点个在看呗~


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存